Passed
Push — master ( b97736...5ab0b6 )
by MusikAnimal
04:31
created

application.js ➔ ... ➔ $.each   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
nc 1
nop 2
dl 0
loc 4
rs 10
c 0
b 0
f 0
1
(function () {
2
    var sortDirection, sortColumn, $tocClone, tocHeight, sectionOffset = {},
3
        toggleTableData, apiPath, lastProject;
4
5
    // Load translations with 'en.json' as a fallback
6
    var messagesToLoad = {};
7
8
    /** global: i18nLang */
9
    /** global: i18nPath */
10
    messagesToLoad[i18nLang] = i18nPath;
11
12
    /** global: i18nEnPath */
13
    if (i18nLang !== 'en') {
14
        messagesToLoad.en = i18nEnPath;
15
    }
16
17
    $.i18n({
18
        locale: i18nLang
19
    }).load(messagesToLoad);
20
21
    $(document).ready(function () {
22
        // TODO: move these listeners to a setup function and document how to use it.
23
        $('.xt-hide').on('click', function () {
24
            $(this).hide();
25
            $(this).siblings('.xt-show').show();
26
27
            if ($(this).parents('.panel-heading').length) {
28
                $(this).parents('.panel-heading').siblings('.panel-body').hide();
29
            } else {
30
                $(this).parents('.xt-show-hide--parent').next('.xt-show-hide--target').hide();
31
            }
32
        });
33
        $('.xt-show').on('click', function () {
34
            $(this).hide();
35
            $(this).siblings('.xt-hide').show();
36
37
            if ($(this).parents('.panel-heading').length) {
38
                $(this).parents('.panel-heading').siblings('.panel-body').show();
39
            } else {
40
                $(this).parents('.xt-show-hide--parent').next('.xt-show-hide--target').show();
41
            }
42
        });
43
44
        setupNavCollapsing();
45
        setupColumnSorting();
46
        setupTOC();
47
        setupStickyHeader();
48
        setupProjectListener();
49
        setupAutocompletion();
50
        displayWaitingNoticeOnSubmission();
51
52
        // Re-init forms, workaround for issues with Safari and Firefox.
53
        // See displayWaitingNoticeOnSubmission() for more.
54
        window.onpageshow = function (e) {
55
            if (e.persisted) {
56
                displayWaitingNoticeOnSubmission(true);
57
            }
58
        };
59
    });
60
61
    /**
62
     * Script to make interactive toggle table and pie chart.
63
     * For visual example, see the "Semi-automated edits" section of the AutoEdits tool.
64
     *
65
     * Example usage (see autoEdits/result.html.twig and js/autoedits.js for more):
66
     *     <table class="table table-bordered table-hover table-striped toggle-table">
67
     *         <thead>...</thead>
68
     *         <tbody>
69
     *             {% for tool, values in semi_automated %}
70
     *             <tr>
71
     *                 <!-- use the 'linked' class here because the cell contains a link -->
72
     *                 <td class="sort-entry--tool linked" data-value="{{ tool }}">
73
     *                     <span class="toggle-table--toggle" data-index="{{ loop.index0 }}" data-key="{{ tool }}">
74
     *                         <span class="glyphicon glyphicon-remove"></span>
75
     *                         <span class="color-icon" style="background:{{ chartColor(loop.index0) }}"></span>
76
     *                     </span>
77
     *                     {{ wiki.pageLink(...) }}
78
     *                 </td>
79
     *                 <td class="sort-entry--count" data-value="{{ values.count }}">
80
     *                     {{ values.count }}
81
     *                 </td>
82
     *             </tr>
83
     *             {% endfor %}
84
     *             ...
85
     *         </tbody>
86
     *     </table>
87
     *     <div class="toggle-table--chart">
88
     *         <canvas id="tool_chart" width="400" height="400"></canvas>
89
     *     </div>
90
     *     <script>
91
     *         window.toolsChart = new Chart($('#tool_chart'), { ... });
92
     *         window.countsByTool = {{ semi_automated | json_encode() | raw }};
93
     *         ...
94
     *
95
     *         // See autoedits.js for more
96
     *         window.setupToggleTable(window.countsByTool, window.toolsChart, 'count', function (newData) {
97
     *             // update the totals in toggle table based on newData
98
     *         });
99
     *     </script>
100
     *
101
     * @param  {Object}   dataSource     Object of data that makes up the chart
102
     * @param  {Chart}    chartObj       Reference to the pie chart associated with the .toggle-table
103
     * @param  {String}   [valueKey]     The name of the key within entries of dataSource,
104
     *                                   where the value is what's shown in the chart.
105
     *                                   If omitted or null, `dataSource` is assumed to be of the structure:
106
     *                                   { 'a' => 123, 'b' => 456 }
107
     * @param  {Function} updateCallback Callback to update the .toggle-table totals. `toggleTableData`
108
     *                                   is passed in which contains the new data, you just need to
109
     *                                   format it (maybe need to use i18n, update multiple cells, etc.).
110
     *                                   The second parameter that is passed back is the 'key' of the toggled
111
     *                                   item, and the third is the index of the item.
112
     */
113
    window.setupToggleTable = function (dataSource, chartObj, valueKey, updateCallback) {
114
        $('.toggle-table').on('click', '.toggle-table--toggle', function () {
115
            if (!toggleTableData) {
116
                // must be cloned
117
                toggleTableData = Object.assign({}, dataSource);
118
            }
119
120
            var index = $(this).data('index'),
121
                key = $(this).data('key');
122
123
            // must use .attr instead of .prop as sorting script will clone DOM elements
124
            if ($(this).attr('data-disabled') === 'true') {
125
                toggleTableData[key] = dataSource[key];
126
                var oldValue = parseInt(valueKey ? toggleTableData[key][valueKey] : toggleTableData[key], 10);
127
                chartObj.data.datasets[0].data[index] = oldValue;
128
                $(this).attr('data-disabled', 'false');
129
            } else {
130
                delete toggleTableData[key];
131
                chartObj.data.datasets[0].data[index] = null;
132
                $(this).attr('data-disabled', 'true');
133
            }
134
135
            // gray out row in table
136
            $(this).parents('tr').toggleClass('excluded');
137
138
            // change the hover icon from a 'x' to a '+'
139
            $(this).find('.glyphicon').toggleClass('glyphicon-remove').toggleClass('glyphicon-plus');
140
141
            // update stats
142
            updateCallback(toggleTableData, key, index);
143
144
            chartObj.update();
145
        });
146
    }
147
148
    /**
149
     * If there are more tool links in the nav than will fit in the viewport,
150
     *   move the last entry to the More menu, one at a time, until it all fits.
151
     * This does not listen for window resize events.
152
     */
153
    function setupNavCollapsing()
154
    {
155
        var windowWidth = $(window).width(),
156
            toolNavWidth = $('.tool-links').outerWidth(),
157
            navRightWidth = $('.nav-buttons').outerWidth();
158
159
        // Ignore if in mobile responsive view
160
        if (windowWidth < 768) {
161
            return;
162
        }
163
164
        // Do this first so we account for the space the More menu takes up
165
        if (toolNavWidth + navRightWidth > windowWidth) {
166
            $('.tool-links--more').removeClass('hidden');
167
        }
168
169
        // Don't loop more than there are links in the nav.
170
        // This more just a safeguard against an infinite loop should something go wrong.
171
        var numLinks = $('.tool-links--entry').length;
172
        while (numLinks > 0 && toolNavWidth + navRightWidth > windowWidth) {
173
            // Remove the last tool link that is not the current tool being used
174
            var $link = $('.tool-links--nav > .tool-links--entry:not(.active)').last().remove();
175
            $('.tool-links--more .dropdown-menu').append($link);
176
            toolNavWidth = $('.tool-links').outerWidth();
177
            numLinks--;
178
        }
179
    }
180
181
    /**
182
     * Sorting of columns
183
     *
184
     *  Example usage:
185
     *   {% for key in ['username', 'edits', 'minor', 'date'] %}
186
     *      <th>
187
     *         <span class="sort-link sort-link--{{ key }}" data-column="{{ key }}">
188
     *            {{ msg(key) | capitalize }}
189
     *            <span class="glyphicon glyphicon-sort"></span>
190
     *         </span>
191
     *      </th>
192
     *  {% endfor %}
193
     *   <th class="sort-link" data-column="username">Username</th>
194
     *   ...
195
     *   <td class="sort-entry--username" data-value="{{ username }}">{{ username }}</td>
196
     *   ...
197
     *
198
     * Data type is automatically determined, with support for integer,
199
     *   floats, and strings, including date strings (e.g. "2016-01-01 12:59")
200
     */
201
    window.setupColumnSorting = function () {
202
        $('.sort-link').on('click', function () {
203
            sortDirection = sortColumn === $(this).data('column') ? -sortDirection : 1;
204
205
            $('.sort-link .glyphicon').removeClass('glyphicon-sort-by-alphabet-alt glyphicon-sort-by-alphabet').addClass('glyphicon-sort');
206
            var newSortClassName = sortDirection === 1 ? 'glyphicon-sort-by-alphabet-alt' : 'glyphicon-sort-by-alphabet';
207
            $(this).find('.glyphicon').addClass(newSortClassName).removeClass('glyphicon-sort');
208
209
            sortColumn = $(this).data('column');
210
            var $table = $(this).parents('table');
211
            var entries = $table.find('.sort-entry--' + sortColumn).parent();
212
213
            if (!entries.length) {
214
                return; }
215
216
            entries.sort(function (a, b) {
217
                var before = $(a).find('.sort-entry--' + sortColumn).data('value'),
218
                    after = $(b).find('.sort-entry--' + sortColumn).data('value');
219
220
                // test data type, assumed to be string if can't be parsed as float
221
                if (!isNaN(parseFloat(before, 10))) {
222
                    before = parseFloat(before, 10);
223
                    after = parseFloat(after, 10);
224
                }
225
226
                if (before < after) {
227
                    return sortDirection;
228
                } else if (before > after) {
229
                    return -sortDirection;
230
                } else {
231
                    return 0;
232
                }
233
            });
234
235
            $table.find('tbody').html($(entries));
236
        });
237
    }
238
239
    /**
240
     * Floating table of contents
241
     *
242
     * Example usage (see articleInfo/result.html.twig for more):
243
     *     <p class="text-center xt-heading-subtitle">
244
     *         ...
245
     *     </p>
246
     *     <div class="text-center xt-toc">
247
     *         {% set sections = ['generalstats', 'usertable', 'yearcounts', 'monthcounts'] %}
248
     *         {% for section in sections %}
249
     *             <span>
250
     *                 <a href="#{{ section }}" data-section="{{ section }}">{{ msg(section) }}</a>
251
     *             </span>
252
     *         {% endfor %}
253
     *     </div>
254
     *     ...
255
     *     {% set content %}
256
     *         ...content for general stats...
257
     *     {% endset %}
258
     *     {{ layout.content_block('generalstats', content) }}
259
     *     ...
260
     */
261
    function setupTOC()
262
    {
263
        var $toc = $('.xt-toc');
264
265
        if (!$toc || !$toc[0]) {
266
            return;
267
        }
268
269
        tocHeight = $toc.height();
270
271
        // listeners on the section links
272
        var setupTocListeners = function () {
273
            $('.xt-toc').find('a').off('click').on('click', function (e) {
274
                document.activeElement.blur();
275
                var $newSection = $('#' + $(e.target).data('section'));
276
                $(window).scrollTop($newSection.offset().top - tocHeight);
277
278
                $(this).parents('.xt-toc').find('a').removeClass('bold');
279
280
                createTocClone();
281
                $tocClone.addClass('bold');
282
            });
283
        };
284
        window.setupTocListeners = setupTocListeners;
285
286
        // clone the TOC and add position:fixed
287
        var createTocClone = function () {
288
            if ($tocClone) {
289
                return;
290
            }
291
            $tocClone = $toc.clone();
292
            $tocClone.addClass('fixed');
293
            $toc.after($tocClone);
294
            setupTocListeners();
295
        };
296
297
        // build object containing offsets of each section
298
        window.buildSectionOffsets = function () {
299
            $.each($toc.find('a'), function (index, tocMember) {
300
                var id = $(tocMember).data('section');
301
                sectionOffset[id] = $('#' + id).offset().top;
302
            });
303
        }
304
305
        // rebuild section offsets when sections are shown/hidden
306
        $('.xt-show, .xt-hide').on('click', buildSectionOffsets);
0 ignored issues
show
Bug introduced by
The variable buildSectionOffsets seems to be never declared. If this is a global, consider adding a /** global: buildSectionOffsets */ comment.

This checks looks for references to variables that have not been declared. This is most likey a typographical error or a variable has been renamed.

To learn more about declaring variables in Javascript, see the MDN.

Loading history...
307
308
        buildSectionOffsets();
309
        setupTocListeners();
310
311
        var tocOffsetTop = $toc.offset().top;
312
        $(window).on('scroll.toc', function (e) {
313
            var windowOffset = $(e.target).scrollTop();
314
            var inRange = windowOffset > tocOffsetTop;
315
316
            if (inRange) {
317
                if (!$tocClone) {
318
                    createTocClone();
319
                }
320
321
                // bolden the link for whichever section we're in
322
                var $activeMember;
323
                Object.keys(sectionOffset).forEach(function (section) {
324
                    if (windowOffset > sectionOffset[section] - tocHeight - 1) {
325
                        $activeMember = $tocClone.find('a[data-section="' + section + '"]');
0 ignored issues
show
Bug introduced by
The variable $tocClone seems to not be initialized for all possible execution paths.
Loading history...
326
                    }
327
                });
328
                $tocClone.find('a').removeClass('bold');
329
                if ($activeMember) {
330
                    $activeMember.addClass('bold');
331
                }
332
            } else if (!inRange && $tocClone) {
333
                // remove the clone once we're out of range
334
                $tocClone.remove();
335
                $tocClone = null;
336
            }
337
        });
338
    }
339
340
    /**
341
     * Make any tables with the class 'table-sticky-header' have sticky headers.
342
     * E.g. as you scroll the heading row will be fixed at the top for reference.
343
     */
344
    function setupStickyHeader()
345
    {
346
        var $header = $('.table-sticky-header');
347
348
        if (!$header || !$header[0]) {
349
            return;
350
        }
351
352
        var headerHeight = $header.height(),
0 ignored issues
show
Unused Code introduced by
The variable headerHeight seems to be never used. Consider removing it.
Loading history...
353
            $headerRow = $header.find('thead tr').eq(0),
354
            $headerClone;
355
356
        // Make a clone of the header to maintain placement of the original header,
357
        // making the original header the sticky one. This way event listeners on it
358
        // (such as column sorting) will still work.
359
        var cloneHeader = function () {
360
            if ($headerClone) {
361
                return;
362
            }
363
364
            $headerClone = $headerRow.clone();
365
            $headerRow.addClass('sticky-heading');
366
            $headerRow.before($headerClone);
367
368
            // Explicitly set widths of each column, which are lost with position:absolute.
369
            $headerRow.find('th').each(function (index) {
370
                $(this).css('width', $headerClone.find('th').eq(index).outerWidth());
371
            });
372
            $headerRow.css('width', $headerClone.outerWidth() + 1);
373
        };
374
375
        var headerOffsetTop = $header.offset().top;
376
        $(window).on('scroll.stickyHeader', function (e) {
377
            var windowOffset = $(e.target).scrollTop();
378
            var inRange = windowOffset > headerOffsetTop;
379
380
            if (inRange && !$headerClone) {
381
                cloneHeader();
382
            } else if (!inRange && $headerClone) {
383
                // Remove the clone once we're out of range,
384
                // and make the original un-sticky.
385
                $headerRow.removeClass('sticky-heading');
386
                $headerClone.remove();
387
                $headerClone = null;
388
            } else if ($headerClone) {
389
                // The header is position:absolute so it will follow with X scrolling,
390
                // but for Y we must go by the window scroll position.
391
                $headerRow.css(
392
                    'top',
393
                    $(window).scrollTop() - $header.offset().top
394
                );
395
            }
396
        });
397
    }
398
399
    /**
400
     * Add listener to the project input field to update any
401
     * namespace selectors and autocompletion fields.
402
     */
403
    function setupProjectListener()
404
    {
405
        // Stop here if there is no project field
406
        if (!$("#project_input")) {
407
            return;
408
        }
409
410
        // If applicable, setup namespace selector with real time updates when changing projects.
411
        // This will also set `apiPath` so that autocompletion will query the right wiki.
412
        if ($('#project_input').length && $('#namespace_select').length) {
413
            setupNamespaceSelector();
414
        // Otherwise, if there's a user or page input field, we still need to update `apiPath`
415
        // for the user input autocompletion when the project is changed.
416
        } else if ($('#user_input')[0] || $('#article_input')[0]) {
417
            // keep track of last valid project
418
            lastProject = $('#project_input').val();
419
420
            $('#project_input').on('change', function () {
421
                var newProject = this.value;
422
423
                // Show the spinner.
424
                $(this).addClass('show-loader');
425
426
                /** global: xtBaseUrl */
427
                $.get(xtBaseUrl + 'api/project/normalize/' + newProject).done(function (data) {
428
                    // Keep track of project API path for use in page title autocompletion
429
                    apiPath = data.api;
430
                    lastProject = newProject;
431
                    setupAutocompletion();
432
                }).fail(
433
                    revertToValidProject.bind(this, newProject)
0 ignored issues
show
Unused Code introduced by
The call to bind does not seem necessary since the function revertToValidProject declared on line 503 does not use this.
Loading history...
434
                ).always(function () {
435
                    $('#project_input').removeClass('show-loader');
436
                });
437
            });
438
        }
439
    }
440
441
    /**
442
     * Use the wiki input field to populate the namespace selector.
443
     * This also updates `apiPath` and calls setupAutocompletion()
444
     */
445
    function setupNamespaceSelector()
446
    {
447
        // keep track of last valid project
448
        lastProject = $('#project_input').val();
449
450
        $('#project_input').on('change', function () {
451
            // Disable the namespace selector and show a spinner while the data loads.
452
            $('#namespace_select').prop('disabled', true);
453
            $(this).addClass('show-loader');
454
455
            var newProject = this.value;
456
457
            /** global: xtBaseUrl */
458
            $.get(xtBaseUrl + 'api/project/namespaces/' + newProject).done(function (data) {
459
                // Clone the 'all' option (even if there isn't one),
460
                // and replace the current option list with this.
461
                var $allOption = $('#namespace_select option[value="all"]').eq(0).clone();
462
                $("#namespace_select").html($allOption);
463
464
                // Keep track of project API path for use in page title autocompletion
465
                apiPath = data.api;
466
467
                // Add all of the new namespace options.
468
                for (var ns in data.namespaces) {
469
                    if (!data.namespaces.hasOwnProperty(ns)) {
470
                        continue; // Skip keys from the prototype.
471
                    }
472
473
                    var nsName = parseInt(ns, 10) === 0 ? $.i18n('mainspace') : data.namespaces[ns];
474
                    $('#namespace_select').append(
475
                        "<option value=" + ns + ">" + nsName + "</option>"
476
                    );
477
                }
478
                // Default to mainspace being selected.
479
                $("#namespace_select").val(0);
480
                lastProject = newProject;
481
482
                // Re-init autocompletion
483
                setupAutocompletion();
484
            }).fail(revertToValidProject.bind(this, newProject)).always(function () {
0 ignored issues
show
Unused Code introduced by
The call to bind does not seem necessary since the function revertToValidProject declared on line 503 does not use this.
Loading history...
485
                $('#namespace_select').prop('disabled', false);
486
                $('#project_input').removeClass('show-loader');
487
            });
488
        });
489
490
        // If they change the namespace, update autocompletion,
491
        // which will ensure only pages in the selected namespace
492
        // show up in the autocompletion
493
        $('#namespace_select').on('change', setupAutocompletion);
494
    }
495
496
    /**
497
     * Called by setupNamespaceSelector or setupProjectListener
498
     *   when the user changes to a project that doesn't exist.
499
     * This throws a warning message and reverts back to the
500
     *   last valid project.
501
     * @param {string} newProject - project they attempted to add
502
     */
503
    function revertToValidProject(newProject)
504
    {
505
        $('#project_input').val(lastProject);
506
        $('.site-notice').append(
507
            "<div class='alert alert-warning alert-dismissible' role='alert'>" +
508
                $.i18n('invalid-project', "<strong>" + newProject + "</strong>") +
509
                "<button class='close' data-dismiss='alert' aria-label='Close'>" +
510
                    "<span aria-hidden='true'>&times;</span>" +
511
                "</button>" +
512
            "</div>"
513
        );
514
    }
515
516
    /**
517
     * Setup autocompletion of pages if a page input field is present.
518
     */
519
    function setupAutocompletion()
520
    {
521
        var $articleInput = $('#article_input'),
522
            $userInput = $('#user_input'),
523
            $namespaceInput = $("#namespace_select");
524
525
        // Make sure typeahead-compatible fields are present
526
        if (!$articleInput[0] && !$userInput[0] && !$('#project_input')[0]) {
527
            return;
528
        }
529
530
        // Destroy any existing instances
531
        if ($articleInput.data('typeahead')) {
532
            $articleInput.data('typeahead').destroy();
533
        }
534
        if ($userInput.data('typeahead')) {
535
            $userInput.data('typeahead').destroy();
536
        }
537
538
        // set initial value for the API url, which is put as a data attribute in forms.html.twig
539
        if (!apiPath) {
540
            apiPath = $('#article_input').data('api') || $('#user_input').data('api');
541
        }
542
543
        // Defaults for typeahead options. preDispatch and preProcess will be
544
        // set accordingly for each typeahead instance
545
        var typeaheadOpts = {
546
            url: apiPath,
547
            timeout: 200,
548
            triggerLength: 1,
549
            method: 'get',
550
            loadingClass: 'show-loader',
551
            preDispatch: null,
552
            preProcess: null,
553
        };
554
555
        if ($articleInput[0]) {
556
            $articleInput.typeahead({
557
                ajax: Object.assign(typeaheadOpts, {
558
                    preDispatch: function (query) {
559
                        // If there is a namespace selector, make sure we search
560
                        // only within that namespace
561
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
562
                            var nsName = $namespaceInput.find('option:selected').text().trim();
563
                            query = nsName + ':' + query;
564
                        }
565
                        return {
566
                            action: 'query',
567
                            list: 'prefixsearch',
568
                            format: 'json',
569
                            pssearch: query
570
                        };
571
                    },
572
                    preProcess: function (data) {
573
                        var nsName = '';
574
                        // Strip out namespace name if applicable
575
                        if ($namespaceInput[0] && $namespaceInput.val() !== '0') {
576
                            nsName = $namespaceInput.find('option:selected').text().trim();
577
                        }
578
                        return data.query.prefixsearch.map(function (elem) {
579
                            return elem.title.replace(new RegExp('^' + nsName + ':'), '');
580
                        });
581
                    },
582
                })
583
            });
584
        }
585
586
        if ($userInput[0]) {
587
            $userInput.typeahead({
588
                ajax: Object.assign(typeaheadOpts, {
589
                    preDispatch: function (query) {
590
                        return {
591
                            action: 'query',
592
                            list: 'prefixsearch',
593
                            format: 'json',
594
                            pssearch: 'User:' + query
595
                        };
596
                    },
597
                    preProcess: function (data) {
598
                        var results = data.query.prefixsearch.map(function (elem) {
599
                            return elem.title.split('/')[0].substr(elem.title.indexOf(':') + 1);
600
                        });
601
602
                        return results.filter(function (value, index, array) {
603
                            return array.indexOf(value) === index;
604
                        });
605
                    },
606
                })
607
            });
608
        }
609
    }
610
611
    /**
612
     * For any form submission, this disables the submit button and replaces its text with
613
     * a loading message and a counting timer.
614
     * @param {boolean} [undo] Revert the form back to the initial state.
615
     *                         This is used on page load to solve an issue with Safari and Firefox
616
     *                         where after browsing back to the form, the "loading" state persists.
617
     */
618
    function displayWaitingNoticeOnSubmission(undo)
619
    {
620
        if (undo) {
621
            // Re-enable form
622
            $('.form-control').prop('readonly', false);
623
            $('.form-submit').prop('disabled', false);
624
            $('.form-submit').text($.i18n('submit')).prop('disabled', false);
625
        } else {
626
            $('#content form').on('submit', function () {
627
                // Remove focus from any active element
628
                document.activeElement.blur();
629
630
                // Disable the form so they can't hit Enter to re-submit
631
                $('.form-control').prop('readonly', true);
632
633
                // Change the submit button text.
634
                $('.form-submit').prop('disabled', true)
635
                    .html($.i18n('loading') + " <span id='submit_timer'></span>");
636
637
                // Add the counter.
638
                var startTime = Date.now();
639
                setInterval(function () {
640
                    var elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
641
                    var minutes = Math.floor(elapsedSeconds / 60);
642
                    var seconds = ('00' + (elapsedSeconds - (minutes * 60))).slice(-2);
643
                    $('#submit_timer').text(minutes + ":" + seconds);
644
                }, 1000);
645
            });
646
        }
647
    }
648
649
})();
650